// ==UserScript== // @name 笔趣阁优化 for iOS // @namespace Violentmonkey Scripts // @match https://lingjingxingzhe.com/*.html // @match https://m.qishuta.org/*.html // @exclude-match https://*/*index*.html // @grant GM.setValue // @grant GM.getValue // @grant GM.deleteValue // @require https://cdn.jsdelivr.net/npm/alpinejs@3.13.0/dist/cdn.min.js // @version 2.0.0 // @author LinHQ // @license GPLv3 // @description 极简的 iOS Safari 端辅助小说阅读脚本 // ==/UserScript== (async () => { // 自定义网站配置 let sites = [ { host: 'lingjingxingzhe.com', title: 'h1.title', content: '#content', filters: ['.header', '.nav', 'body>:not(.container,.m-setting,#ird-main)'], banRegex: [/打开站内搜索即可阅读/g, /\s?[||]\s?/g] }, { host: 'm.qishuta.org', title: '#chaptertitle', content: '#novelcontent', filters: ['.msitetext', 'body > :not(.main,.ird-main)', '#strl', 'script+div', '#novelcontent .novelbutton', '#novelcontent :not(br,hr,h3)', 'body style+div'], banRegex: [/[((]本章.+?继续阅读[))]/g, /第.+?(
){2}/g, /(
){2}.+?继续阅读[))]/g, /百度.+?即可阅读/g, /搜.+?最新章节/g, /全网首发/g, /新阅读.+?网站/g, /第.章.+?[))]/g] } ] const appTemplate = `

LinHQ1999/LinHQ1999@qq.com

` function App() { return { loading: false, loaded: [], // 注意,如果改用 petite-vue 则不支持写在里面,只能挪到外面去 pointers: new Set(), nextObserver: null, hideApp: false, config: { showTOC: false, hideToolbar: false, fontSize: 18, theme: 'light', version: '2.0.0', htmlMode: false, readingURL: document.URL }, // 获取阅读信息存储的 key get storeKey() { const path = (new URL(document.URL)).pathname.split('/') path.pop() return path.pop() }, async init() { // 先获取保存的配置 const saved = await GM.getValue(this.storeKey) if (saved && this.config.version === saved.version) { if (saved.readingURL && saved.readingURL !== this.config.readingURL) { if (confirm('是否跳转到上次阅读章节?')) { document.location = saved.readingURL } } Object.assign(this.config, saved) this.config.readingURL = document.URL } let doc = this.parseDoc(document.body) this.loaded.push({key: new URL(document.URL).pathname, doc}) this.nextObserver = new IntersectionObserver(this.handleNext.bind(this), { // 进入可见区域之前进行检测 root: this.$refs.contentWrapper, rootMargin: '100%'//getComputedStyle(this.$refs.contentWrapper).height }) // 在初始化元素变更完之后再开始各种监听 await this.switchObserver(true) this.$watch('config', async () => { await GM.setValue(this.storeKey, this.config) }) }, async switchObserver(start) { if (start) { await this.$nextTick() this.nextObserver.observe(this.$refs.segment) } else { this.nextObserver.disconnect() } }, // 修正 重排模式 下的各种显示问题 fixFormat(text) { let result = text.replaceAll(/[^\S\n]{2}/g, ' ') return result }, // 解析 doc 到 object parseDoc(doc) { const result = {}, previous = this.loaded.length > 0 ? this.loaded[this.loaded.length - 1] : null const currentSite = sites.find(site => document.URL.includes(site.host)) // 解析链接 for (const a of doc.querySelectorAll('a')) { if (a.textContent.includes('上一')) result.prev = a.href else if (a.textContent.includes('下一')) result.next = a.href else if (a.textContent.includes('目录')) { result.toc = a.href } } // 标题取得 result.currentTitle = doc.querySelector(currentSite.title)?.textContent // 内容取得 const contentEle = doc.querySelector(currentSite.content) currentSite.filters.forEach(reg => { contentEle.querySelectorAll(reg).forEach(ele => ele.remove()) }) let html = contentEle.innerHTML let text = contentEle.textContent currentSite.banRegex.forEach(reg => { text = text.replaceAll(reg, '') html = html.replaceAll(reg, '') }) result.html = html.replaceAll(/( ){2,}/g, '  ') result.text = this.fixFormat(text) // 没有 previous 就是第一个直接解析的页面 result.showTitle = previous ? result.currentTitle !== previous.doc.currentTitle : true // 虽然说 key 就是这个,但是加上也没啥 result.URL = previous?.doc.next ?? document.URL return result }, handleTap(e) { // 提高多点下效率 if (this.pointers.size === 0) return const container = this.$refs.contentWrapper const viewHeight = parseInt(getComputedStyle(container).height), viewWidth = parseInt(getComputedStyle(container).width) const pageLength = viewHeight - this.config.fontSize * 1.5 switch (this.pointers.size) { case 1: // 不妨直接获取倒数第二块的rect计算滚动距离 if (e.clientX < viewWidth / 3) { // window.scrollBy(0, -1 * viewHeight - fontSize * lineHeight * 1.5) container.scrollBy(0, pageLength) } else if (e.clientX > viewWidth * 3 / 4) { container.scrollBy(0, pageLength) } else {} break case 3: this.config.hideToolbar = !this.config.hideToolbar break } this.pointers.clear() }, toggleTheme() { this.config.theme = this.config.theme === 'light' ? 'dark' : 'light' }, async handleNext(entries) { if (!entries[0].isIntersecting) { return } await this.switchObserver(false) // 下面开始执行无限加载 try { const {doc: now} = this.loaded[this.loaded.length - 1] const newDoc = await this.fetchDocument(now.next) const newParsedDoc = this.parseDoc(newDoc) this.loaded.push({key: now.next, doc: newParsedDoc}) // 新加载的可能还没滚到,所以还是保存上一章的 URL this.config.readingURL = now.URL } catch (e) { console.error(e) } finally { await this.switchObserver(true) } }, // html 直拼还是 textContent 修正 async toggleParseMode() { await this.switchObserver(false) this.config.htmlMode = !this.config.htmlMode await this.switchObserver(true) }, async navigate(action) { await this.switchObserver(false) const current = this.loaded[this.loaded.length > 2 ? this.loaded.length - 2 : 0].doc const cached = this.loaded.find(rec => rec.key === current[action]) // 切换之后无论如何显示标题 if (cached) { cached.doc.showTitle = true this.loaded = [cached] } else { const page = await this.fetchDocument(current[action]) const doc = this.parseDoc(page) doc.showTitle = true this.loaded = [{key: current[action], doc}] } this.config.readingURL = current[action] await this.switchObserver(true) }, toToc() { this.hideApp = true this.config.readingURL = undefined document.location = this.loaded[0].doc.toc }, async fetchDocument(url) { this.loading = true const page = await fetch(url).then(resp => resp.arrayBuffer()), decoder = new TextDecoder(document.characterSet) this.loading = false return new DOMParser().parseFromString(decoder.decode(page), 'text/html') } } } // 用 WebComponent class IReader extends HTMLElement { constructor() { super() this.root = this.attachShadow({mode: 'closed'}) this.root.innerHTML = appTemplate } connectedCallback() { Alpine.data('App', App) // closed shadowRoot 无法通过遍历获取,需要传给 Alpine Alpine.initTree(this.root) } } customElements.define('i-reader', IReader) const ird = document.createElement('i-reader') ird.id = 'ird-main' ird.className = 'ird-main show' document.body.appendChild(ird) })()